iT邦幫忙

2026 iThome 鐵人賽

DAY 8
2
Modern Web

Angular 22 Signal 進化論系列 第 8 篇

Day 8:用 Orval 串起 Angular API Contract:同時產生 HttpClient、httpResource 與 Zod

  • 分享至 

  • xImage
  •  

前後端在對接 API 時,常見的問題之一,就是雙方對資料格式的定義逐漸不一致。

前端串接 API 時,通常會根據後端提供的 Response 格式,定義對應的 TypeScript 型別:

export interface User {
  id: number;
  name: string;
  email: string;
}

這樣 TypeScript 可以協助檢查欄位名稱與型別,也能提供完整的型別提示。

但這份 User 型別只代表前端預期收到的資料格式,無法保證 Runtime 實際收到的 Response 一定符合這份定義。

例如前端定義:

id: number;

但後端實際回傳:

{
  "id": "1"
}

即使把 Response 標記成 User,TypeScript 也不會在 Runtime 檢查 "1" 是否真的符合 number。TypeScript 能檢查程式碼如何使用資料,但無法驗證 Server 實際回傳的內容。

因此,如果希望資料在進入應用程式前就先確認格式,可以加入 Runtime Schema Validation,例如搭配 Zod 使用。

使用 Zod 驗證 httpResource 的 Response

httpResource() 提供 parse,可以在 HTTP Response 成為 Resource 資料前先進行解析或驗證。

先定義 Zod Schema:

import { z } from 'zod';

export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
});

再把 Zod 的 parse() 交給 httpResource():

userResource = httpResource(
  () => `/api/users/${this.userId()}`,
  {
    parse: UserSchema.parse,
  }
);

Response 回來後會先經過 UserSchema.parse(),驗證通過才會成為 Resource 的資料;如果格式不符合 Schema,parse() 會拋出錯誤,Resource 也會進入 Error 狀態。

回到前面 id 回傳 "1" 的例子,這次 "1" 不符合 z.number(),就會在 parse() 這一步被擋下來,而不是一路帶進畫面之後才發現問題。

TypeScript Type 和 Zod Schema 負責的階段不同:

TypeScript Type 和 Zod Schema 差異

TypeScript 負責描述程式預期使用的資料結構,Zod 則是在 Runtime 驗證實際收到的資料是否符合這份定義。

不過,如果後端維護自己的 DTO,前端又另外手寫 TypeScript Type 和 Zod Schema,同一份資料結構就會被分散在多個地方維護,也增加彼此不同步的風險。

前後端各自維護型別導致不一致問題

例如後端新增了一個欄位:

phone: string;

如果前端的 TypeScript Type 或 Zod Schema 沒有一起更新,前後端對這支 API 的定義就可能開始不同。

Zod 可以驗證實際收到的資料是否符合目前這份 Schema,但如果這份 Schema 本身也是由前端手動維護,它仍然可能和後端真正的 API 定義產生落差。

這時問題就不只是「有沒有 Runtime Validation」,而是 API Contract 是否來自同一份來源。

API Contract 是否來自同一份來源

如果前後端都依賴同一份 API Contract,就能降低重複維護資料定義所造成的落差。

這時可以使用 OpenAPI 作為 API 的共同規格,再交給 Orval 讀取,產生前端串接 API 所需要的 TypeScript 型別、API Client,以及搭配 Zod 產生的 Runtime Schema。

即使 Angular 已經提供 httpResource(),也不代表所有 GET Request 都需要改用它。實際開發時,仍然可以依情境選擇 HttpClient 或 httpResource()。

Orval 可以同時產生這兩種讀取方式,讓前端共用同一份 OpenAPI Contract,再依需求選擇適合的 API Client。

例如可以建立 orval.config.ts:

import { defineConfig } from 'orval';

export default defineConfig({
  todoApi: {
    input: {
      // OpenAPI 規格來源
      // 優先讀取環境變數,沒有設定時則使用本機 NestJS 的 Swagger JSON
      target:
        process.env.OPENAPI_URL ??
        'http://localhost:3100/api-json',
    },
    output: {
      // 依照 OpenAPI Tag 拆分產生的 API 檔案
      mode: 'tags-split',
      // API Client 輸出位置
      target: 'src/api/generated/todo-api.ts',
      // Schema 產生設定
      schemas: {
        // Schema 輸出位置
        path: 'src/api/generated/model',
        // 使用 Zod 產生 Runtime Schema
        type: 'zod',
      },
      // 使用 Angular Generator 產生 API Client
      client: 'angular',
      // 重新產生前先清除舊的 generated files
      clean: true,
      override: {
        angular: {
          // 同時產生 HttpClient 與 httpResource 的讀取方式
          retrievalClient: 'both',
          // 使用產生的 Zod Schema 驗證 API Response
          runtimeValidation: true,
          // 為這組 API 建立 Angular DI 的 Base URL 設定
          baseUrl: {
            apiId: 'todo',
          },
        },
        zod: {
          // 使用 Zod v4
          version: 4,
        },
      },
    },
  },
});

簡單看一下產出的結果:

Swagger 對應生成檔案

以 TodoList 為例,Orval 產生的內容可以先分成幾個部分來看:

  • app、todos:依照 OpenAPI Tag 拆分出不同的 API Client,讀取類型的 API 可以同時提供 HttpClient 與 httpResource() 兩種使用方式。
  • model:根據 OpenAPI Schema 產生對應的 Zod Schema,以及 Client 使用的型別,並搭配產生的 Client 在 Runtime 驗證 API Response 是否符合 Contract。
  • todo-api.base-url.ts:產生這組 API 對應的 Base URL DI Token 與 Provider,讓整組 API 可以共用同一套 Base URL 設定。

Base URL 可以在 app.config.ts 統一注入,並搭配 Angular 的 environment 設定,依照開發、測試或正式環境切換不同的 API 位址。

import { provideHttpClient } from '@angular/common/http';
import {
  ApplicationConfig,
  provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideTodoBaseUrl } from '../api/generated/todo-api.base-url';
import { environment } from '../environments/environment';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideHttpClient(),
    // Orval 產生的 Client 會從這裡取得 Base URL
    provideTodoBaseUrl(environment.apiBaseUrl),
  ],
};

這樣產生的 HttpClient 與 httpResource() Client 就不需要把 API 位址寫死在程式碼裡,而是統一從 Angular DI 取得目前環境使用的 Base URL。

實際專案中,也可以把 Orval 納入 CI/CD 流程,在建置或部署前重新讀取最新的 OpenAPI 規格並產生前端程式碼。當後端 API 發生變更時,只要 OpenAPI 規格同步更新,前端就能重新產生對應的 Type、Client 與 Zod Schema,而不需要再逐一手動同步。

整體流程可以整理成:
Backend、Orval、Angular 流程圖

OpenAPI 負責提供共同的 API Contract,Orval 負責把這份 Contract 轉成前端可以直接使用的程式碼,而 Zod 則負責在 Runtime 驗證 Server 真正回傳的資料。

本日結語

今天主要處理 API 串接中的兩個問題:

  • TypeScript 只能在開發階段提供型別保護:無法保證 Runtime 真正收到的資料一定符合預期,因此需要搭配 Zod 進行 Runtime Validation。
  • 前後端各自維護資料定義容易產生落差:如果 DTO、Type 和 Schema 都需要手動同步,時間久了很容易出現不一致,因此可以透過 OpenAPI 與 Orval 讓它們來自同一份 API Contract。

Zod 補上了 Runtime Validation,OpenAPI 則可以作為前後端共同的 API Contract,再透過 Orval 把這份規格轉成前端可以直接使用的 Type、Client 與 Schema。這樣前端不需要重複手動維護同一份資料結構,也能在真正收到 Response 時再做一次驗證。

實際導入時,更重要的是把這套流程放進日常開發裡。例如後端調整 API 後同步更新 OpenAPI,前端透過 Orval 重新產生程式碼,甚至進一步整合到 CI/CD,讓 Contract 的同步盡量由流程保證,而不是依賴開發者記得手動修改。

不過這套做法仍然建立在一個前提上:OpenAPI 規格本身必須正確。如果後端實際回傳的資料沒有被正確描述在 Response Schema 裡,後續產生的 Type、Client 與 Zod Schema 也會跟著失去參考價值。

所以真正重要的不是單純導入 Zod 或 Orval,而是讓 API Contract 成為前後端共同依賴的來源,並讓它實際參與程式碼產生、Runtime 驗證與 CI/CD 流程。這樣 Contract 才不只是文件,而是整個 API 開發流程的一部分。

資料來源


上一篇
Day 7:從 Resource 到 httpResource:用 Signal 管理 HTTP 資料請求
下一篇
Day 9:httpResource 實務篇,統一回覆格式與 chain 相依請求
系列文
Angular 22 Signal 進化論 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言